refactor(shopinbit): await send-from navigation before returning true
What changed, and why it matters
This commit fixes a timing bug in the Stack Wallet app's ShopInBit payment flow. Previously, the code that launches the in-wallet send screen was called without waiting for it to finish, and the app could continue running logic on a screen that had already been closed. The change makes the navigation awaitable and adds a check to stop further work if the widget is no longer visible. This is a correctness and stability fix that could prevent UI glitches or inconsistent order state, but it is not a clear-cut security vulnerability.
Treat as a routine bug-fix / hardening commit. No immediate security response is indicated, but verify that all call sites of `tryNavigateToShopInBitWalletSend` now await the result and that no other unawaited navigation paths in the ShopInBit flow leave state updates exposed to widget lifecycle races.
Security signals we found
Use of unawaited async navigation removed
mounted-state guard added after async navigation
Order status update now occurs only after navigation completes or fails
Potential race between navigation completion and widget lifecycle addressed
Evidence from the diff
The patch converts _confirmPayment, _pushShopInBitSendFrom, and tryNavigateToShopInBitWalletSend from synchronous void/bool functions to async Future<void>/Future<bool> functions and awaits the navigation calls. It also adds if (!mounted) return; guards after the awaited navigation in both shopinbit_car_research_payment_view.dart and shopinbit_payment_view.dart. The previous use of unawaited(...) for showDialog/Navigator.push meant post-navigation state updates could execute while the widget was unmounted or before the send flow completed. The fix ensures the send flow completes before the caller decides whether to update order status or show an external-payment prompt.
Changed components
lib/pages/shopinbit/shopinbit_car_research_payment_view.dartlib/pages/shopinbit/shopinbit_payment_shared.dartlib/pages/shopinbit/shopinbit_payment_view.dartInspect captured patch +26 / −30
diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
index 484fed7..af854f7 100644
--- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
@@ -89,7 +89,7 @@ class _ShopInBitCarResearchPaymentViewState
bool get _payNowEnabled =>
!_isTerminal && _flowState == _PaymentFlowState.idle;
- void _confirmPayment() {
+ Future<void> _confirmPayment() async {
// Keep polling while the user is in the send flow.
final method = _methods[_selectedMethod];
final ticker = method.toUpperCase();
@@ -100,7 +100,7 @@ class _ShopInBitCarResearchPaymentViewState
coin: AppConfig.getCryptoCurrencyForTicker(ticker),
);
- final navigated = tryNavigateToShopInBitWalletSend(
+ final navigated = await tryNavigateToShopInBitWalletSend(
ref: ref,
context: context,
ticker: ticker,
@@ -113,6 +113,7 @@ class _ShopInBitCarResearchPaymentViewState
);
if (navigated) return;
+ if (!mounted) return;
// No compatible wallet coin found: surface an info flushbar and keep
// the user on this screen so they can pay externally and then use the
@@ -826,7 +827,7 @@ class _ShopInBitCarResearchPaymentViewState
enabled: _payNowEnabled,
onPressed: _payNowEnabled
? (hasWallets
- ? _confirmPayment
+ ? () => unawaited(_confirmPayment())
: () => unawaited(_checkForPayment()))
: null,
),
diff --git a/lib/pages/shopinbit/shopinbit_payment_shared.dart b/lib/pages/shopinbit/shopinbit_payment_shared.dart
index fab7f89..c70f253 100644
--- a/lib/pages/shopinbit/shopinbit_payment_shared.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_shared.dart
@@ -1,5 +1,3 @@
-import 'dart:async';
-
import 'package:decimal/decimal.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -127,7 +125,8 @@ bool hasShopInBitWalletForTicker({
return wallets.wallets.any((e) => e.info.coin == coin);
}
-void _pushShopInBitSendFrom({
+// Pushes the send-from view and awaits it.
+Future<void> _pushShopInBitSendFrom({
required BuildContext context,
required CryptoCurrency coin,
required Amount? amount,
@@ -136,26 +135,24 @@ void _pushShopInBitSendFrom({
EthContract? tokenContract,
bool popDesktopBeforeShow = false,
String? routeOnSuccessName,
-}) {
+}) async {
if (Util.isDesktop) {
if (popDesktopBeforeShow) {
Navigator.of(context, rootNavigator: true).pop();
}
- unawaited(
- showDialog<void>(
- context: context,
- builder: (_) => ShopInBitSendFromView(
- coin: coin,
- amount: amount,
- address: address,
- model: model,
- shouldPopRoot: true,
- tokenContract: tokenContract,
- ),
+ await showDialog<void>(
+ context: context,
+ builder: (_) => ShopInBitSendFromView(
+ coin: coin,
+ amount: amount,
+ address: address,
+ model: model,
+ shouldPopRoot: true,
+ tokenContract: tokenContract,
),
);
} else {
- Navigator.of(context).push(
+ await Navigator.of(context).push(
RouteGenerator.getRoute<dynamic>(
shouldUseMaterialRoute: RouteGenerator.useMaterialPageRoute,
builder: (_) => ShopInBitSendFromView(
@@ -172,11 +169,8 @@ void _pushShopInBitSendFrom({
}
}
-// Tries to launch the in-wallet send flow for [ticker]/[address]. Returns
-// true when navigation happened. Returns false when no compatible wallet
-// or token contract was found, leaving the caller to handle the
-// "pay externally" path (flushbar, status change, etc).
-bool tryNavigateToShopInBitWalletSend({
+// Tries to launch the in-wallet send flow for [ticker]/[address].
+Future<bool> tryNavigateToShopInBitWalletSend({
required WidgetRef ref,
required BuildContext context,
required String ticker,
@@ -186,12 +180,12 @@ bool tryNavigateToShopInBitWalletSend({
required ShopInBitOrderModel model,
bool popDesktopBeforeShow = false,
String? routeOnSuccessName,
-}) {
+}) async {
if (address.isEmpty) return false;
final coin = AppConfig.getCryptoCurrencyForTicker(ticker);
if (coin != null) {
- _pushShopInBitSendFrom(
+ await _pushShopInBitSendFrom(
context: context,
coin: coin,
amount: amount,
@@ -211,7 +205,7 @@ bool tryNavigateToShopInBitWalletSend({
if (tokenContract != null) {
final ethCoin = AppConfig.getCryptoCurrencyForTicker("ETH");
if (ethCoin != null) {
- _pushShopInBitSendFrom(
+ await _pushShopInBitSendFrom(
context: context,
coin: ethCoin,
amount: amount,
diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart
index fce3892..e876a71 100644
--- a/lib/pages/shopinbit/shopinbit_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -244,7 +244,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
}
}
- void _confirmPayment() {
+ Future<void> _confirmPayment() async {
_pollTimer?.cancel();
final method = _methods[_selectedMethod];
final ticker = method.toUpperCase();
@@ -256,7 +256,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
amountFallback: _paymentInfo?.due,
);
- if (tryNavigateToShopInBitWalletSend(
+ if (await tryNavigateToShopInBitWalletSend(
ref: ref,
context: context,
ticker: ticker,
@@ -268,6 +268,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
)) {
return;
}
+ if (!mounted) return;
widget.model.status = ShopInBitOrderStatus.paymentPending;
widget.model.paymentMethod = method;
@@ -306,7 +307,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
void _onOwnedCoinTap(int methodIndex) {
if (!_payNowEnabled) return;
_selectedMethod = methodIndex;
- _confirmPayment();
+ unawaited(_confirmPayment());
}
void _onUnownedCoinTap(int methodIndex) {
Why this scored 26/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.