show split tx warning on confirm screen
What changed, and why it matters
This commit updates the Stack Wallet app so that when a single payment is broken into multiple cryptocurrency transactions, the user sees a clear warning on the confirmation screen. It also changes the app's internal record-keeping to handle multiple transaction IDs (txids) instead of assuming there is always just one. The main risk being addressed is that a user might accidentally send a split payment to a vendor or exchange that only credits single-transaction payments, leading to lost or miscredited funds.
No immediate security action is required; this is a defensive UX and data-model improvement. Reviewers should verify that all call sites consuming `TxData.txid` handle the null case safely when multiple txids are present, and that the new `txids` list is never empty after a successful send. Users should update to the version containing this commit to benefit from the split-payment warning.
Security signals we found
UI now warns users when a payment will be split into multiple on-chain transactions
Internal transaction model changed from single txid to list of txids
Multiple confirmation flows updated to iterate over all txids for notes and trade lookups
Dependency bump of cs_monero from 3.2.0 to 4.0.0 to support multi-txid pending transactions
Generated FFI templates parse semicolon-separated transaction hashes for Salvium and Wownero
Evidence from the diff
The patch migrates TxData from a single optional txid field to a List<String> txids field, with txid retained as a convenience getter only when exactly one txid exists. UI confirmation pages (CakePay, ChangeNow, ShopInBit, and the generic send view) now render a new SplitTransactionWarning widget when txids.length > 1. Wallet-specific send logic for Monero, Salvium, and Wownero now propagates the pending transaction’s txids list. Supporting service code (trade lookup, note saving) iterates over all txids rather than a single one. The cs_monero dependency is bumped from 3.2.0 to 4.0.0, and generated FFI templates are updated to parse semicolon-separated txids from the underlying libraries.
Changed components
lib/pages/send_view/confirm_transaction_view.dartlib/pages/send_view/sub_widgets/split_transaction_warning.dartlib/pages/cakepay/cakepay_confirm_send_view.dartlib/pages/exchange_view/confirm_change_now_send.dartlib/pages/shopinbit/shopinbit_confirm_send_view.dartlib/services/trade_sent_from_stack_service.dartlib/wallets/models/tx_data.dartlib/wallets/wallet/intermediate/lib_monero_wallet.dartlib/wallets/wallet/intermediate/lib_salvium_wallet.dartlib/wallets/wallet/intermediate/lib_wownero_wallet.dartlib/wl_gen/interfaces/cs_monero_interface.dartcs_monero package dependency (4.0.0)Inspect captured patch +376 / −239
### lib/pages/cakepay/cakepay_confirm_send_view.dart
@@ -28,6 +28,7 @@ import '../../widgets/rounded_white_container.dart';
import '../../widgets/stack_dialog.dart';
import '../pinpad_views/lock_screen_view.dart';
import '../send_view/sub_widgets/sending_transaction_dialog.dart';
+import '../send_view/sub_widgets/split_transaction_warning.dart';
import '../wallet_view/wallet_view.dart';
class CakePayConfirmSendView extends ConsumerStatefulWidget {
@@ -80,7 +81,6 @@ class _CakePayConfirmSendViewState
final time = Future<dynamic>.delayed(const Duration(milliseconds: 2500));
- late String txid;
final String note = widget.txData.note ?? "";
try {
@@ -93,13 +93,14 @@ class _CakePayConfirmSendViewState
sendProgressController.triggerSuccess?.call();
await Future<void>.delayed(const Duration(seconds: 5));
- txid = (results.first as TxData).txid!;
-
- await ref
- .read(mainDBProvider)
- .putTransactionNote(
- TransactionNote(walletId: walletId, txid: txid, value: note),
- );
+ final confirmedTx = results.first as TxData;
+ for (final txid in confirmedTx.txids) {
+ await ref
+ .read(mainDBProvider)
+ .putTransactionNote(
+ TransactionNote(walletId: walletId, txid: txid, value: note),
+ );
+ }
if (context.mounted) {
// pop sending dialog (pushed via showDialog which uses root navigator)
@@ -147,9 +148,9 @@ class _CakePayConfirmSendViewState
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.buttonTextSecondary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
),
),
onPressed: () {
@@ -239,13 +240,13 @@ class _CakePayConfirmSendViewState
builder: (child) {
return Background(
child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.backgroundAppBar,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .backgroundAppBar,
leading: AppBarBackButton(
onPressed: () async {
Navigator.of(context).pop();
@@ -309,9 +310,9 @@ class _CakePayConfirmSendViewState
children: [
RoundedWhiteContainer(
padding: const EdgeInsets.all(0),
- borderColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
child: child,
),
const SizedBox(height: 16),
@@ -327,9 +328,9 @@ class _CakePayConfirmSendViewState
),
const SizedBox(height: 10),
RoundedContainer(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@@ -338,22 +339,21 @@ class _CakePayConfirmSendViewState
.watch(pAmountFormatter(coin))
.format(widget.txData.fee!),
style:
- STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
- ),
+ STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
),
],
),
),
const SizedBox(height: 16),
RoundedContainer(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.snackBarBackSuccess,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .snackBarBackSuccess,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -385,6 +385,13 @@ class _CakePayConfirmSendViewState
],
),
),
+ if (widget.txData.txids.length > 1)
+ Padding(
+ padding: const .only(top: 16),
+ child: SplitTransactionWarning(
+ transactionCount: widget.txData.txids.length,
+ ),
+ ),
const SizedBox(height: 16),
Row(
children: [
@@ -437,9 +444,9 @@ class _CakePayConfirmSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -458,9 +465,9 @@ class _CakePayConfirmSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -482,9 +489,9 @@ class _CakePayConfirmSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -505,9 +512,9 @@ class _CakePayConfirmSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -531,9 +538,9 @@ class _CakePayConfirmSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -552,9 +559,9 @@ class _CakePayConfirmSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -576,18 +583,18 @@ class _CakePayConfirmSendViewState
if (!isDesktop) const SizedBox(height: 12),
if (!isDesktop)
RoundedContainer(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.snackBarBackSuccess,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .snackBarBackSuccess,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Total amount",
style: STextStyles.titleBold12(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textConfirmTotalAmount,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textConfirmTotalAmount,
),
),
Builder(
@@ -598,9 +605,9 @@ class _CakePayConfirmSendViewState
return Text(
ref.watch(pAmountFormatter(coin)).format(total),
style: STextStyles.itemSubtitle12(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textConfirmTotalAmount,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textConfirmTotalAmount,
),
textAlign: TextAlign.right,
);
@@ -611,6 +618,13 @@ class _CakePayConfirmSendViewState
),
if (!isDesktop) const SizedBox(height: 16),
if (!isDesktop) const Spacer(),
+ if (!isDesktop && widget.txData.txids.length > 1)
+ Padding(
+ padding: const .only(bottom: 16),
+ child: SplitTransactionWarning(
+ transactionCount: widget.txData.txids.length,
+ ),
+ ),
if (!isDesktop)
PrimaryButton(
label: "Send",
### lib/pages/exchange_view/confirm_change_now_send.dart
@@ -43,6 +43,7 @@ import '../../widgets/rounded_white_container.dart';
import '../../widgets/stack_dialog.dart';
import '../pinpad_views/lock_screen_view.dart';
import '../send_view/sub_widgets/sending_transaction_dialog.dart';
+import '../send_view/sub_widgets/split_transaction_warning.dart';
import '../wallet_view/wallet_view.dart';
class ConfirmChangeNowSendView extends ConsumerStatefulWidget {
@@ -114,7 +115,6 @@ class _ConfirmChangeNowSendViewState
final time = Future<dynamic>.delayed(const Duration(milliseconds: 2500));
- late String txid;
Future<TxData> txidFuture;
final String note = widget.txData.note ?? "";
@@ -133,25 +133,27 @@ class _ConfirmChangeNowSendViewState
sendProgressController.triggerSuccess?.call();
await Future<void>.delayed(const Duration(seconds: 5));
- txid = (results.first as TxData).txid!;
-
- // save note
- await ref
- .read(mainDBProvider)
- .putTransactionNote(
- TransactionNote(walletId: walletId, txid: txid, value: note),
- );
+ final confirmedTx = results.first as TxData;
+ for (final txid in confirmedTx.txids) {
+ await ref
+ .read(mainDBProvider)
+ .putTransactionNote(
+ TransactionNote(walletId: walletId, txid: txid, value: note),
+ );
+ }
- await ref
- .read(tradeSentFromStackLookupProvider)
- .save(
- tradeWalletLookup: TradeWalletLookup(
- uuid: const Uuid().v1(),
- txid: txid,
- tradeId: trade.tradeId,
- walletIds: [walletId],
- ),
- );
+ for (final txid in confirmedTx.txids) {
+ await ref
+ .read(tradeSentFromStackLookupProvider)
+ .save(
+ tradeWalletLookup: TradeWalletLookup(
+ uuid: const Uuid().v1(),
+ txid: txid,
+ tradeId: trade.tradeId,
+ walletIds: [walletId],
+ ),
+ );
+ }
// pop back to wallet
if (context.mounted) {
@@ -191,9 +193,9 @@ class _ConfirmChangeNowSendViewState
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.buttonTextSecondary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
),
),
onPressed: () {
@@ -281,13 +283,13 @@ class _ConfirmChangeNowSendViewState
builder: (child) {
return Background(
child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.backgroundAppBar,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .backgroundAppBar,
leading: AppBarBackButton(
onPressed: () async {
// if (FocusScope.of(context).hasFocus) {
@@ -355,9 +357,9 @@ class _ConfirmChangeNowSendViewState
children: [
RoundedWhiteContainer(
padding: const EdgeInsets.all(0),
- borderColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
child: child,
),
const SizedBox(height: 16),
@@ -373,9 +375,9 @@ class _ConfirmChangeNowSendViewState
),
const SizedBox(height: 10),
RoundedContainer(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@@ -388,22 +390,21 @@ class _ConfirmChangeNowSendViewState
)
.format(widget.txData.fee!),
style:
- STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
- ),
+ STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
),
],
),
),
const SizedBox(height: 16),
RoundedContainer(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.snackBarBackSuccess,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .snackBarBackSuccess,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -437,6 +438,13 @@ class _ConfirmChangeNowSendViewState
],
),
),
+ if (widget.txData.txids.length > 1)
+ Padding(
+ padding: const .only(top: 16),
+ child: SplitTransactionWarning(
+ transactionCount: widget.txData.txids.length,
+ ),
+ ),
const SizedBox(height: 16),
Row(
children: [
@@ -489,9 +497,9 @@ class _ConfirmChangeNowSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -510,9 +518,9 @@ class _ConfirmChangeNowSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -535,9 +543,9 @@ class _ConfirmChangeNowSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -589,9 +597,9 @@ class _ConfirmChangeNowSendViewState
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle2,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle2,
),
);
},
@@ -616,9 +624,9 @@ class _ConfirmChangeNowSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -644,9 +652,9 @@ class _ConfirmChangeNowSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -665,9 +673,9 @@ class _ConfirmChangeNowSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -687,18 +695,18 @@ class _ConfirmChangeNowSendViewState
if (!isDesktop) const SizedBox(height: 12),
if (!isDesktop)
RoundedContainer(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.snackBarBackSuccess,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .snackBarBackSuccess,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Total amount",
style: STextStyles.titleBold12(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textConfirmTotalAmount,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textConfirmTotalAmount,
),
),
Builder(
@@ -713,9 +721,9 @@ class _ConfirmChangeNowSendViewState
return Text(
ref.watch(pAmountFormatter(coin)).format(total),
style: STextStyles.itemSubtitle12(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textConfirmTotalAmount,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textConfirmTotalAmount,
),
textAlign: TextAlign.right,
);
@@ -726,6 +734,13 @@ class _ConfirmChangeNowSendViewState
),
if (!isDesktop) const SizedBox(height: 16),
if (!isDesktop) const Spacer(),
+ if (!isDesktop && widget.txData.txids.length > 1)
+ Padding(
+ padding: const .only(bottom: 16),
+ child: SplitTransactionWarning(
+ transactionCount: widget.txData.txids.length,
+ ),
+ ),
if (!isDesktop)
PrimaryButton(
label: "Send",
### lib/pages/send_view/confirm_transaction_view.dart
@@ -69,6 +69,7 @@ import '../wallet_view/wallet_view.dart';
import 'sub_widgets/epic_slatepack_dialog.dart';
import 'sub_widgets/mwc_slatepack_dialog.dart';
import 'sub_widgets/sending_transaction_dialog.dart';
+import 'sub_widgets/split_transaction_warning.dart';
class ConfirmTransactionView extends ConsumerStatefulWidget {
const ConfirmTransactionView({
@@ -451,7 +452,7 @@ class _ConfirmTransactionViewState
} else if (wallet is FiroWallet && confirmedTx.sparkSpends != null) {
txids.addAll(confirmedTx.sparkSpends!.map((e) => e.txid!));
} else {
- txids.add(confirmedTx.txid!);
+ txids.addAll(confirmedTx.txids);
}
if (coin is! Ethereum) {
ref.refresh(desktopUseUTXOs);
@@ -1496,6 +1497,15 @@ class _ConfirmTransactionViewState
),
),
),
+ if (widget.txData.txids.length > 1)
+ Padding(
+ padding: isDesktop
+ ? const .symmetric(horizontal: 32, vertical: 16)
+ : const .symmetric(vertical: 8),
+ child: SplitTransactionWarning(
+ transactionCount: widget.txData.txids.length,
+ ),
+ ),
if (_spendsOrdinal)
Padding(
padding: isDesktop
### lib/pages/send_view/sub_widgets/split_transaction_warning.dart
@@ -0,0 +1,56 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_svg/flutter_svg.dart';
+
+import '../../../themes/stack_colors.dart';
+import '../../../utilities/assets.dart';
+import '../../../utilities/text_styles.dart';
+import '../../../widgets/rounded_container.dart';
+
+class SplitTransactionWarning extends StatelessWidget {
+ const SplitTransactionWarning({super.key, required this.transactionCount});
+
+ final int transactionCount;
+
+ @override
+ Widget build(BuildContext context) {
+ final colors = Theme.of(context).extension<StackColors>()!;
+ return RoundedContainer(
+ color: colors.warningBackground,
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.stretch,
+ children: [
+ Row(
+ children: [
+ SvgPicture.asset(
+ Assets.svg.alertCircle,
+ width: 20,
+ height: 20,
+ colorFilter: ColorFilter.mode(colors.warningForeground, .srcIn),
+ ),
+ const SizedBox(width: 8),
+ Expanded(
+ child: Text(
+ 'Warning: split payment',
+ style: STextStyles.pageTitleH2(context)
+ .copyWith(color: colors.warningForeground),
+ ),
+ ),
+ ],
+ ),
+ const SizedBox(height: 12),
+ Text(
+ 'This payment will be sent as $transactionCount '
+ 'separate transactions. Some vendors and swap services '
+ 'require a single transaction and may not credit '
+ 'your full payment.\n\n'
+ 'Only continue if the recipient accepts split payments. '
+ 'If you are unsure, go back and contact the recipient '
+ 'before sending.',
+ style: STextStyles.smallMed14(context)
+ .copyWith(color: colors.warningForeground),
+ ),
+ ],
+ ),
+ );
+ }
+}
### lib/pages/shopinbit/shopinbit_confirm_send_view.dart
@@ -32,6 +32,7 @@ import '../../widgets/rounded_white_container.dart';
import '../../widgets/stack_dialog.dart';
import '../pinpad_views/lock_screen_view.dart';
import '../send_view/sub_widgets/sending_transaction_dialog.dart';
+import '../send_view/sub_widgets/split_transaction_warning.dart';
import '../wallet_view/wallet_view.dart';
class ShopInBitConfirmSendView extends ConsumerStatefulWidget {
@@ -89,7 +90,6 @@ class _ShopInBitConfirmSendViewState
final time = Future<dynamic>.delayed(const Duration(milliseconds: 2500));
- late String txid;
Future<TxData> txidFuture;
final String note = widget.txData.note ?? "";
@@ -111,14 +111,14 @@ class _ShopInBitConfirmSendViewState
sendProgressController.triggerSuccess?.call();
await Future<void>.delayed(const Duration(seconds: 5));
- txid = (results.first as TxData).txid!;
-
- // save note
- await ref
- .read(mainDBProvider)
- .putTransactionNote(
- TransactionNote(walletId: walletId, txid: txid, value: note),
- );
+ final confirmedTx = results.first as TxData;
+ for (final txid in confirmedTx.txids) {
+ await ref
+ .read(mainDBProvider)
+ .putTransactionNote(
+ TransactionNote(walletId: walletId, txid: txid, value: note),
+ );
+ }
// The server (and the BTCPay webhook) own ticket + payment state from
// here, so there's nothing to persist locally; just nudge a refresh so
@@ -132,9 +132,7 @@ class _ShopInBitConfirmSendViewState
final popThroughRouteName = widget.popThroughRouteName;
if (popThroughRouteName != null) {
final navigator = Navigator.of(context, rootNavigator: true);
- navigator.popUntil(
- ModalRoute.withName(popThroughRouteName),
- );
+ navigator.popUntil(ModalRoute.withName(popThroughRouteName));
navigator.pop();
} else {
// pop sending dialog (pushed via showDialog which uses root navigator)
@@ -145,9 +143,8 @@ class _ShopInBitConfirmSendViewState
Navigator.of(context, rootNavigator: true).pop();
}
- Navigator.of(
- context,
- ).popUntil(ModalRoute.withName(routeOnSuccessName));
+ Navigator.of(context)
+ .popUntil(ModalRoute.withName(routeOnSuccessName));
}
}
} catch (e, s) {
@@ -176,9 +173,9 @@ class _ShopInBitConfirmSendViewState
child: Text(
"Ok",
style: STextStyles.button(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.buttonTextSecondary,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .buttonTextSecondary,
),
),
onPressed: () {
@@ -273,13 +270,13 @@ class _ShopInBitConfirmSendViewState
builder: (child) {
return Background(
child: Scaffold(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
appBar: AppBar(
- backgroundColor: Theme.of(
- context,
- ).extension<StackColors>()!.backgroundAppBar,
+ backgroundColor: Theme.of(context)
+ .extension<StackColors>()!
+ .backgroundAppBar,
leading: AppBarBackButton(
onPressed: () async {
Navigator.of(context).pop();
@@ -343,9 +340,9 @@ class _ShopInBitConfirmSendViewState
children: [
RoundedWhiteContainer(
padding: const EdgeInsets.all(0),
- borderColor: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ borderColor: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
child: child,
),
const SizedBox(height: 16),
@@ -361,9 +358,9 @@ class _ShopInBitConfirmSendViewState
),
const SizedBox(height: 10),
RoundedContainer(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textFieldDefaultBG,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textFieldDefaultBG,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@@ -376,22 +373,21 @@ class _ShopInBitConfirmSendViewState
)
.format(widget.txData.fee!),
style:
- STextStyles.desktopTextExtraExtraSmall(
- context,
- ).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textDark,
- ),
+ STextStyles.desktopTextExtraExtraSmall(context)
+ .copyWith(
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textDark,
+ ),
),
],
),
),
const SizedBox(height: 16),
RoundedContainer(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.snackBarBackSuccess,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .snackBarBackSuccess,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
@@ -443,6 +439,13 @@ class _ShopInBitConfirmSendViewState
],
),
),
+ if (widget.txData.txids.length > 1)
+ Padding(
+ padding: const .only(top: 16),
+ child: SplitTransactionWarning(
+ transactionCount: widget.txData.txids.length,
+ ),
+ ),
const SizedBox(height: 16),
Row(
children: [
@@ -495,9 +498,9 @@ class _ShopInBitConfirmSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -518,9 +521,9 @@ class _ShopInBitConfirmSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -542,9 +545,9 @@ class _ShopInBitConfirmSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -597,9 +600,9 @@ class _ShopInBitConfirmSendViewState
STextStyles.desktopTextExtraExtraSmall(
context,
).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textSubtitle2,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textSubtitle2,
),
);
},
@@ -625,9 +628,9 @@ class _ShopInBitConfirmSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -653,9 +656,9 @@ class _ShopInBitConfirmSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -674,9 +677,9 @@ class _ShopInBitConfirmSendViewState
),
isDesktop
? Container(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.background,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .background,
height: 1,
)
: const SizedBox(height: 12),
@@ -696,18 +699,18 @@ class _ShopInBitConfirmSendViewState
if (!isDesktop) const SizedBox(height: 12),
if (!isDesktop)
RoundedContainer(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.snackBarBackSuccess,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .snackBarBackSuccess,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"Total amount",
style: STextStyles.titleBold12(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textConfirmTotalAmount,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textConfirmTotalAmount,
),
),
Builder(
@@ -737,9 +740,9 @@ class _ShopInBitConfirmSendViewState
return Text(
ref.watch(pAmountFormatter(coin)).format(total),
style: STextStyles.itemSubtitle12(context).copyWith(
- color: Theme.of(
- context,
- ).extension<StackColors>()!.textConfirmTotalAmount,
+ color: Theme.of(context)
+ .extension<StackColors>()!
+ .textConfirmTotalAmount,
),
textAlign: TextAlign.right,
);
@@ -750,6 +753,13 @@ class _ShopInBitConfirmSendViewState
),
if (!isDesktop) const SizedBox(height: 16),
if (!isDesktop) const Spacer(),
+ if (!isDesktop && widget.txData.txids.length > 1)
+ Padding(
+ padding: const .only(bottom: 16),
+ child: SplitTransactionWarning(
+ transactionCount: widget.txData.txids.length,
+ ),
+ ),
if (!isDesktop)
PrimaryButton(
label: "Send",
### lib/services/trade_sent_from_stack_service.dart
@@ -38,10 +38,8 @@ class TradeSentFromStackService extends ChangeNotifier {
List<String>? getWalletIdsForTradeId(String tradeId) {
final matches = all.where((e) => e.tradeId == tradeId);
- if (matches.length == 1) {
- return matches.first.walletIds;
- }
- return null;
+ if (matches.isEmpty) return null;
+ return matches.expand((e) => e.walletIds).toSet().toList();
}
List<String>? getWalletIdForTxid(String txid) {
@@ -53,9 +51,7 @@ class TradeSentFromStackService extends ChangeNotifier {
return null;
}
- Future<void> save({
- required TradeWalletLookup tradeWalletLookup,
- }) async {
+ Future<void> save({required TradeWalletLookup tradeWalletLookup}) async {
await DB.instance.put(
boxName: DB.boxNameTradeLookup,
key: tradeWalletLookup.uuid,
@@ -64,9 +60,7 @@ class TradeSentFromStackService extends ChangeNotifier {
notifyListeners();
}
- Future<void> delete({
- required TradeWalletLookup tradeWalletLookup,
- }) async {
+ Future<void> delete({required TradeWalletLookup tradeWalletLookup}) async {
await DB.instance.delete<TradeWalletLookup>(
key: tradeWalletLookup.uuid,
boxName: DB.boxNameTradeLookup,
### lib/wallets/models/tx_data.dart
@@ -45,7 +45,10 @@ class TxData {
final String? raw;
- final String? txid;
+ final List<String> txids;
+
+ /// Only available when this send contains exactly one transaction.
+ String? get txid => txids.length == 1 ? txids.single : null;
final String? txHash;
final String? note;
@@ -127,7 +130,8 @@ class TxData {
this.fee,
this.vSize,
this.raw,
- this.txid,
+ String? txid,
+ List<String>? txids,
this.txHash,
this.note,
this.noteOnChain,
@@ -164,7 +168,7 @@ class TxData {
this.opReturnData,
this.type = TxType.regular,
this.salviumStakeTx = false,
- });
+ }) : txids = List.unmodifiable(txids ?? (txid == null ? <String>[] : [txid]));
Amount? get amount {
if (recipients != null && recipients!.isNotEmpty) {
@@ -273,6 +277,7 @@ class TxData {
int? vSize,
String? raw,
String? txid,
+ List<String>? txids,
String? txHash,
String? note,
String? noteOnChain,
@@ -327,7 +332,7 @@ class TxData {
fee: fee ?? this.fee,
vSize: vSize ?? this.vSize,
raw: raw ?? this.raw,
- txid: txid ?? this.txid,
+ txids: txids ?? (txid == null ? this.txids : [txid]),
txHash: txHash ?? this.txHash,
note: note ?? this.note,
noteOnChain: noteOnChain ?? this.noteOnChain,
@@ -379,7 +384,7 @@ class TxData {
'fee: $fee, '
'vSize: $vSize, '
'raw: $raw, '
- 'txid: $txid, '
+ 'txids: $txids, '
'txHash: $txHash, '
'note: $note, '
'noteOnChain: $noteOnChain, '
### lib/wallets/wallet/intermediate/lib_monero_wallet.dart
@@ -1396,6 +1396,7 @@ abstract class LibMoneroWallet<T extends CryptonoteCurrency>
return txData.copyWith(
fee: realFee,
pendingTransaction: pendingTransaction,
+ txids: pendingTransaction.txids,
);
});
} catch (e) {
@@ -1426,9 +1427,9 @@ abstract class LibMoneroWallet<T extends CryptonoteCurrency>
await csMonero.commitTx(wallet!, txData.pendingTransaction!);
Logging.instance.d(
- "transaction ${txData.pendingTransaction!.txid} has been sent",
+ "transactions ${txData.pendingTransaction!.txids} have been sent",
);
- return txData.copyWith(txid: txData.pendingTransaction!.txid);
+ return txData.copyWith(txids: txData.pendingTransaction!.txids);
} catch (e, s) {
Logging.instance.e(
"${info.name} ${compatType.name.toLowerCase()} confirmSend: ",
### lib/wallets/wallet/intermediate/lib_salvium_wallet.dart
@@ -1362,6 +1362,7 @@ abstract class LibSalviumWallet<T extends CryptonoteCurrency>
return txData.copyWith(
fee: realFee,
pendingSalviumTransaction: pendingTransaction,
+ txids: pendingTransaction.txids,
);
});
} catch (e) {
@@ -1392,9 +1393,9 @@ abstract class LibSalviumWallet<T extends CryptonoteCurrency>
await csSalvium.commitTx(wallet!, txData.pendingSalviumTransaction!);
Logging.instance.d(
- "transaction ${txData.pendingSalviumTransaction!.txid} has been sent",
+ "transactions ${txData.pendingSalviumTransaction!.txids} have been sent",
);
- return txData.copyWith(txid: txData.pendingSalviumTransaction!.txid);
+ return txData.copyWith(txids: txData.pendingSalviumTransaction!.txids);
} catch (e, s) {
Logging.instance.e(
"${info.name} confirmSend: ",
### lib/wallets/wallet/intermediate/lib_wownero_wallet.dart
@@ -1374,6 +1374,7 @@ abstract class LibWowneroWallet<T extends CryptonoteCurrency>
return txData.copyWith(
fee: realFee,
pendingTransaction: pendingTransaction,
+ txids: pendingTransaction.txids,
);
});
} catch (e) {
@@ -1404,9 +1405,9 @@ abstract class LibWowneroWallet<T extends CryptonoteCurrency>
await csWownero.commitTx(wallet!, txData.pendingTransaction!);
Logging.instance.d(
- "transaction ${txData.pendingTransaction!.txid} has been sent",
+ "transactions ${txData.pendingTransaction!.txids} have been sent",
);
- return txData.copyWith(txid: txData.pendingTransaction!.txid);
+ return txData.copyWith(txids: txData.pendingTransaction!.txids);
} catch (e, s) {
Logging.instance.e(
"${info.name} ${compatType.name.toLowerCase()} confirmSend: ",
### lib/wl_gen/interfaces/cs_monero_interface.dart
@@ -219,10 +219,11 @@ final class CsPendingTransaction {
// stupid duplicates
final BigInt amount, fee;
- // stupid duplicate
- final String txid;
+ /// One hash per transaction in the pending payment.
+ final List<String> txids;
- const CsPendingTransaction(this.value, this.amount, this.fee, this.txid);
+ CsPendingTransaction(this.value, this.amount, this.fee, List<String> txids)
+ : txids = List.unmodifiable(txids);
}
// forwarding class
### pubspec.lock
@@ -432,10 +432,10 @@ packages:
dependency: "direct main"
description:
name: cs_monero
- sha256: b174f40e1887eb589e1e9aa99de8e9d0bc97b543f2330d5e5e7b01a6d313a9c2
+ sha256: b59aa01fee0d963184ecb94e0d1febccc7ea64aa219c804553ba9a82c97a9059
url: "https://pub.dev"
source: hosted
- version: "3.2.0"
+ version: "4.0.0"
cs_monero_flutter_libs:
dependency: "direct main"
description:
### scripts/app_config/templates/pubspec.template.yaml
@@ -72,7 +72,7 @@ dependencies:
# %%END_ENABLE_TOR%%
# %%ENABLE_XMR%%
-# cs_monero: 3.2.0
+# cs_monero: 4.0.0
# cs_monero_flutter_libs: 2.0.1
# %%END_ENABLE_XMR%%
### tool/wl_templates/SAL_cs_salvium_interface_impl.template.dart
@@ -1,4 +1,6 @@
//ON
+import 'dart:ffi';
+
import 'package:cs_salvium/cs_salvium.dart' as lib_salvium;
import 'package:cs_salvium/src/deprecated/get_height_by_date.dart'
as cs_salvium_deprecated;
@@ -394,7 +396,12 @@ class _CsSalviumInterfaceImpl extends CsSalviumInterface {
pending,
pending.amount,
pending.fee,
- pending.txid,
+ sal_wallet_ffi
+ .getPendingTransactionTxid(
+ Pointer<Void>.fromAddress(pending.pointerAddress),
+ separator: ';',
+ )
+ .split(';'),
);
}
@@ -443,7 +450,12 @@ class _CsSalviumInterfaceImpl extends CsSalviumInterface {
pending,
pending.amount,
pending.fee,
- pending.txid,
+ sal_wallet_ffi
+ .getPendingTransactionTxid(
+ Pointer<Void>.fromAddress(pending.pointerAddress),
+ separator: ';',
+ )
+ .split(';'),
);
}
@@ -495,7 +507,12 @@ class _CsSalviumInterfaceImpl extends CsSalviumInterface {
pending,
pending.amount,
pending.fee,
- pending.txid,
+ sal_wallet_ffi
+ .getPendingTransactionTxid(
+ Pointer<Void>.fromAddress(pending.pointerAddress),
+ separator: ';',
+ )
+ .split(';'),
);
}
### tool/wl_templates/WOW_cs_wownero_interface_impl.template.dart
@@ -1,4 +1,6 @@
//ON
+import 'dart:ffi';
+
import 'package:cs_wownero/cs_wownero.dart' as lib_wownero;
import 'package:cs_wownero/src/deprecated/get_height_by_date.dart'
as cs_wownero_deprecated;
@@ -391,7 +393,12 @@ class _CsWowneroInterfaceImpl extends CsWowneroInterface {
pending,
pending.amount,
pending.fee,
- pending.txid,
+ wow_wallet_ffi
+ .getPendingTransactionTxid(
+ Pointer<Void>.fromAddress(pending.pointerAddress),
+ separator: ';',
+ )
+ .split(';'),
);
}
@@ -443,7 +450,12 @@ class _CsWowneroInterfaceImpl extends CsWowneroInterface {
pending,
pending.amount,
pending.fee,
- pending.txid,
+ wow_wallet_ffi
+ .getPendingTransactionTxid(
+ Pointer<Void>.fromAddress(pending.pointerAddress),
+ separator: ';',
+ )
+ .split(';'),
);
}
### tool/wl_templates/XMR_cs_monero_interface_impl.template.dart
@@ -405,7 +405,7 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface {
pending,
pending.amount,
pending.fee,
- pending.txid,
+ pending.txids,
);
}
@@ -457,7 +457,7 @@ class _CsMoneroInterfaceImpl extends CsMoneroInterface {
pending,
pending.amount,
pending.fee,
- pending.txid,
+ pending.txids,
);
}
Why this scored 32/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.