Cw 1117 prevent sending a transaction twice by mistake (#2567)
What changed, and why it matters
This update fixes a user-interface bug where someone could accidentally trigger a cryptocurrency send twice in a row, for example by double-tapping a button. The app now disables the send button once a transaction has been successfully prepared, and it properly cancels the prepared transaction if the confirmation bottom sheet is closed without sliding to confirm. This reduces the chance of sending funds twice or leaving a half-prepared transaction open.
Treat as a functional reliability fix rather than a critical security vulnerability. Review whether any other send flows still rely on local flags or lack `ExecutedSuccessfullyState` disabling, and verify that `dismissTransaction()` correctly resets all relevant view-model state.
Security signals we found
UI-level double-submit prevention
State-driven button disabling after successful transaction preparation
Bottom-sheet dismissal now clears prepared transaction state
Reverts earlier client-side flag guard in favor of view-model state
Evidence from the diff
The commit reverts an earlier local flag-based double-send guard in SendPage and replaces it with state-driven disabling. It adds ExecutedSuccessfullyState to the isDisabled conditions on send/purchase buttons in SendPage, CakePayBuyCardPage, ExchangeTradePage, and RBFDetailsPage. It also makes the success bottom sheets return a boolean result; if the sheet is dismissed (result == null), dismissTransaction() is called to clear the prepared transaction state. This prevents duplicate transaction creation when the UI remains on the same page after a successful execution.
Changed components
lib/src/screens/send/send_page.dartlib/cake_pay/src/cards/cake_pay_buy_card_page.dartlib/src/screens/exchange_trade/exchange_trade_page.dartlib/src/screens/transaction_details/rbf_details_page.dartInspect captured patch +16 / −19
diff --git a/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart b/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart
index 68d60601..1d53f9b4 100644
--- a/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart
+++ b/lib/cake_pay/src/cards/cake_pay_buy_card_page.dart
@@ -371,7 +371,7 @@ class CakePayBuyCardPage extends BasePage {
},
text: S.of(context).purchase_gift_card,
isDisabled: !cakePayBuyCardViewModel.isAmountSufficient ||
- cakePayBuyCardViewModel.isPurchasing,
+ cakePayBuyCardViewModel.isPurchasing || _sendViewModel.state is ExecutedSuccessfullyState,
isLoading: _sendViewModel.state is IsExecutingState ||
cakePayBuyCardViewModel.isPurchasing,
color: Theme.of(context).colorScheme.primary,
diff --git a/lib/src/screens/exchange_trade/exchange_trade_page.dart b/lib/src/screens/exchange_trade/exchange_trade_page.dart
index 17b6fd71..1e8e74a2 100644
--- a/lib/src/screens/exchange_trade/exchange_trade_page.dart
+++ b/lib/src/screens/exchange_trade/exchange_trade_page.dart
@@ -194,7 +194,8 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
!(sendingState is TransactionCommitted)),
child: LoadingPrimaryButton(
key: ValueKey('exchange_trade_page_send_from_cake_button_key'),
- isDisabled: trade.inputAddress == null || trade.inputAddress!.isEmpty,
+ isDisabled: trade.inputAddress == null || trade.inputAddress!.isEmpty ||
+ sendingState is ExecutedSuccessfullyState,
isLoading: sendingState is IsExecutingState,
onPressed: () => widget.exchangeTradeViewModel.confirmSending(),
text: S.current.send_from_cake_wallet,
@@ -272,9 +273,9 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
}
if (state is ExecutedSuccessfullyState) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
+ WidgetsBinding.instance.addPostFrameCallback((_) async {
if (context.mounted) {
- showModalBottomSheet<void>(
+ final result = await showModalBottomSheet<bool>(
context: context,
isDismissible: false,
isScrollControlled: true,
@@ -304,13 +305,16 @@ class ExchangeTradeState extends State<ExchangeTradeForm> {
outputs: widget.exchangeTradeViewModel.sendViewModel.outputs,
onSlideActionComplete: () async {
if (bottomSheetContext.mounted) {
- Navigator.of(bottomSheetContext).pop();
+ Navigator.of(bottomSheetContext).pop(true);
}
widget.exchangeTradeViewModel.sendViewModel.commitTransaction(context);
},
);
},
);
+
+ if (result == null) widget.exchangeTradeViewModel.sendViewModel.dismissTransaction();
+
}
});
}
diff --git a/lib/src/screens/send/send_page.dart b/lib/src/screens/send/send_page.dart
index 29154156..f5d18073 100644
--- a/lib/src/screens/send/send_page.dart
+++ b/lib/src/screens/send/send_page.dart
@@ -64,7 +64,6 @@ class SendPage extends BasePage {
final PaymentRequest? initialPaymentRequest;
bool _effectsInstalled = false;
- bool _sendInProgress = false;
ContactRecord? newContactAddress;
@override
@@ -413,9 +412,6 @@ class SendPage extends BasePage {
return LoadingPrimaryButton(
key: ValueKey('send_page_send_button_key'),
onPressed: () async {
- // Prevent double taps
- if (_sendInProgress) return;
-
//Request dummy node to get the focus out of the text fields
FocusScope.of(context).requestFocus(FocusNode());
@@ -474,8 +470,6 @@ class SendPage extends BasePage {
}
}
- _sendInProgress = true;
-
final check = sendViewModel.shouldDisplayTotp();
authService.authenticateAction(
context,
@@ -483,8 +477,6 @@ class SendPage extends BasePage {
onAuthSuccess: (value) async {
if (value) {
await sendViewModel.createTransaction();
- } else {
- _sendInProgress = false;
}
},
);
@@ -496,7 +488,7 @@ class SendPage extends BasePage {
sendViewModel.state is TransactionCommitting ||
sendViewModel.state is IsAwaitingDeviceResponseState ||
sendViewModel.state is LoadingTemplateExecutingState,
- isDisabled: !sendViewModel.isReadyForSend,
+ isDisabled: !sendViewModel.isReadyForSend || sendViewModel.state is ExecutedSuccessfullyState,
);
},
)
@@ -533,7 +525,6 @@ class SendPage extends BasePage {
}
if (state is FailureState) {
- _sendInProgress = false;
WidgetsBinding.instance.addPostFrameCallback(
(_) {
showPopUp<void>(
@@ -614,13 +605,11 @@ class SendPage extends BasePage {
);
if (result == null) sendViewModel.dismissTransaction();
- _sendInProgress = false;
}
});
}
if (state is TransactionCommitted) {
- _sendInProgress = false;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!context.mounted) {
return;
diff --git a/lib/src/screens/transaction_details/rbf_details_page.dart b/lib/src/screens/transaction_details/rbf_details_page.dart
index d83ea5b8..98d267d6 100644
--- a/lib/src/screens/transaction_details/rbf_details_page.dart
+++ b/lib/src/screens/transaction_details/rbf_details_page.dart
@@ -105,6 +105,7 @@ class RBFDetailsPage extends BasePage {
text: S.of(context).send,
isLoading:
transactionDetailsViewModel.sendViewModel.state is IsExecutingState,
+ isDisabled: transactionDetailsViewModel.sendViewModel.state is ExecutedSuccessfullyState,
color: Theme.of(context).colorScheme.primary,
textColor: Theme.of(context).colorScheme.onPrimary,
))),
@@ -180,9 +181,9 @@ class RBFDetailsPage extends BasePage {
}
if (state is ExecutedSuccessfullyState) {
- WidgetsBinding.instance.addPostFrameCallback((_) {
+ WidgetsBinding.instance.addPostFrameCallback((_) async {
if (context.mounted) {
- showModalBottomSheet<void>(
+ final result = await showModalBottomSheet<bool>(
context: context,
isDismissible: false,
isScrollControlled: true,
@@ -214,6 +215,9 @@ class RBFDetailsPage extends BasePage {
);
},
);
+ if (result == null) {
+ transactionDetailsViewModel.sendViewModel.dismissTransaction();
+ }
}
});
}
Why this scored 46/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.