fix(shopinbit): require a live invoice before opening the payment view
What changed, and why it matters
This commit hardens the checkout flow for a built-in shopping feature (ShopInBit). Previously, the payment screen could open even if no live invoice with payment addresses had been loaded, and it tried to recover or render empty payment links. Now the app refuses to open the payment screen unless a usable invoice is already in hand, and it disables the 'Pay Now' action when an address is missing. This reduces the chance a user accidentally sends money to an empty/invalid destination or gets stuck in a broken payment state.
Treat as a defensive hardening improvement. Review whether any other entry points can still push ShopInBitPaymentView without a validated PaymentInfo, and confirm fetchShopInBitPaymentInfo cannot return an empty PaymentInfo object that bypasses the new isNotEmpty check.
Security signals we found
Precondition enforcement: payment view now requires a live invoice before opening
Removal of fallback recovery logic that could render empty payment links
UI guard added to prevent 'Pay Now' when payment address is empty
Route argument type changed from nullable PaymentInfo? to required PaymentInfo
Error dialog shown to user instead of opening broken payment view
Evidence from the diff
The patch makes PaymentInfo a required (non-nullable) parameter for ShopInBitPaymentView and removes the in-view recovery path (_recoverPaymentInfo). The shipping view now fetches the invoice and validates paymentInfo != null && paymentInfo.paymentLinks.isNotEmpty before navigating; otherwise it shows an error dialog. Route generators were updated to reject nullable PaymentInfo. The payment view also guards _onOwnedCoinTap and the pay-now UI against empty addresses. The change is defensive hardening rather than a fix for a demonstrated exploit.
Changed components
lib/pages/shopinbit/shopinbit_payment_view.dartlib/pages/shopinbit/shopinbit_shipping_view.dartlib/route_generator.dartlib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dartInspect captured patch +105 / −92
diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart
index af80536..8f4105c 100644
--- a/lib/pages/shopinbit/shopinbit_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -32,16 +32,15 @@ class ShopInBitPaymentView extends ConsumerStatefulWidget {
const ShopInBitPaymentView({
super.key,
required this.model,
- this.initialPaymentInfo,
+ required this.paymentInfo,
});
static const String routeName = "/shopInBitPayment";
final ShopInBitOrderModel model;
- // Pre-loaded by the caller (see fetchShopInBitPaymentInfo) so the view can
- // render populated immediately instead of fetching after it's pushed.
- final PaymentInfo? initialPaymentInfo;
+ // Caller loads this before pushing, so we always open with usable addresses.
+ final PaymentInfo paymentInfo;
@override
ConsumerState<ShopInBitPaymentView> createState() =>
@@ -80,27 +79,10 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
@override
void initState() {
super.initState();
- if (widget.initialPaymentInfo != null) {
- _applyPaymentInfo(widget.initialPaymentInfo!);
- }
+ _applyPaymentInfo(widget.paymentInfo);
if (widget.model.apiTicketId != 0) {
- // If the pre-load didn't hand us usable payment links, recover them:
- // GET, then PUT to generate one.
- if (_addresses.every((a) => a.isEmpty)) {
- unawaited(_recoverPaymentInfo());
- } else {
- _startPolling();
- }
- }
- }
-
- Future<void> _recoverPaymentInfo() async {
- final info = await fetchShopInBitPaymentInfo(ref, widget.model.apiTicketId);
- if (!mounted) return;
- if (info != null) {
- setState(() => _applyPaymentInfo(info));
+ _startPolling();
}
- _startPolling();
}
@override
@@ -289,6 +271,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
void _onOwnedCoinTap(int methodIndex) {
if (!_payNowEnabled) return;
+ if (_addresses[methodIndex].isEmpty) return;
_selectedMethod = methodIndex;
unawaited(_confirmPayment());
}
@@ -362,14 +345,14 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
for (int i = 0; i < _methods.length; i++) {
final ticker = _methods[i].toUpperCase();
final coin = AppConfig.getCryptoCurrencyForTicker(ticker);
+ final hasAddress = _addresses[i].isNotEmpty;
final hasWallet = hasShopInBitWalletForTicker(
wallets: wallets,
ticker: ticker,
paymentUri: _addresses[i],
);
- final amountStr = _addresses[i].isNotEmpty
- ? _parseBip21Amount(_addresses[i])
- : null;
+ final canPayNow = hasWallet && hasAddress;
+ final amountStr = hasAddress ? _parseBip21Amount(_addresses[i]) : null;
if (i > 0) {
coinRows.add(const SizedBox(height: 8));
@@ -378,11 +361,13 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
coinRows.add(
RoundedWhiteContainer(
child: Opacity(
- opacity: hasWallet ? 1.0 : 0.5,
+ opacity: canPayNow ? 1.0 : 0.5,
child: InkWell(
- onTap: hasWallet
- ? () => _onOwnedCoinTap(i)
- : () => _onUnownedCoinTap(i),
+ onTap: !hasAddress
+ ? null
+ : (hasWallet
+ ? () => _onOwnedCoinTap(i)
+ : () => _onUnownedCoinTap(i)),
child: Row(
children: [
if (coin != null)
@@ -419,7 +404,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
],
),
),
- if (hasWallet)
+ if (canPayNow)
Text("PAY NOW", style: STextStyles.link2(context))
else
SvgPicture.asset(
diff --git a/lib/pages/shopinbit/shopinbit_shipping_view.dart b/lib/pages/shopinbit/shopinbit_shipping_view.dart
index d62e9b4..88be18d 100644
--- a/lib/pages/shopinbit/shopinbit_shipping_view.dart
+++ b/lib/pages/shopinbit/shopinbit_shipping_view.dart
@@ -20,6 +20,7 @@ import '../../widgets/detail_item.dart';
import '../../widgets/desktop/desktop_dialog_close_button.dart';
import '../../widgets/desktop/primary_button.dart';
import '../../widgets/dialogs/s_dialog.dart';
+import '../../widgets/stack_dialog.dart';
import '../../widgets/textfields/adaptive_text_field.dart';
import 'shopinbit_payment_shared.dart';
import 'shopinbit_payment_view.dart';
@@ -194,70 +195,84 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
// and the user picked one here.
widget.model.deliveryCountry = country;
- // Pre-load the payment info before pushing the payment view so it renders
- // populated immediately. The Continue button's spinner (_submitting)
- // already covers this wait.
+ // The payment view needs a live invoice, so load it here and only navigate
+ // once we have usable payment links.
+ if (widget.model.apiTicketId == 0) {
+ // No ticket, nothing to invoice.
+ await _showPaymentLoadError(
+ "This request isn't ready for payment yet. Please try again.",
+ );
+ return;
+ }
+
PaymentInfo? paymentInfo;
- if (widget.model.apiTicketId != 0) {
- setState(() => _submitting = true);
- try {
- // Split name into first/last
- final parts = name.split(' ');
- final firstName = parts.first;
- final lastName = parts.length > 1 ? parts.sublist(1).join(' ') : '';
-
- Address? billingAddress;
- if (_differentBilling) {
- final billingName = _billingNameController.text.trim();
- final billingParts = billingName.split(' ');
- final billingFirst = billingParts.first;
- final billingLast = billingParts.length > 1
- ? billingParts.sublist(1).join(' ')
- : '';
- billingAddress = Address(
- firstName: billingFirst,
- lastName: billingLast,
- street: _billingStreetController.text.trim(),
- zip: _billingPostalCodeController.text.trim(),
- city: _billingCityController.text.trim(),
- country: _billingSelectedCountryIso!,
- );
- }
-
- final resp = await ref
- .read(pShopinBitService)
- .client
- .submitAddress(
- widget.model.apiTicketId,
- shipping: Address(
- firstName: firstName,
- lastName: lastName,
- street: street,
- zip: postalCode,
- city: city,
- country: country,
- ),
- billing: billingAddress,
- );
+ setState(() => _submitting = true);
+ try {
+ // Split name into first/last
+ final parts = name.split(' ');
+ final firstName = parts.first;
+ final lastName = parts.length > 1 ? parts.sublist(1).join(' ') : '';
+
+ Address? billingAddress;
+ if (_differentBilling) {
+ final billingName = _billingNameController.text.trim();
+ final billingParts = billingName.split(' ');
+ final billingFirst = billingParts.first;
+ final billingLast = billingParts.length > 1
+ ? billingParts.sublist(1).join(' ')
+ : '';
+ billingAddress = Address(
+ firstName: billingFirst,
+ lastName: billingLast,
+ street: _billingStreetController.text.trim(),
+ zip: _billingPostalCodeController.text.trim(),
+ city: _billingCityController.text.trim(),
+ country: _billingSelectedCountryIso!,
+ );
+ }
- if (resp.hasError) {
- // Sandbox may fail here; continue anyway.
- debugPrint("submitAddress failed: ${resp.exception?.message}");
- }
+ final resp = await ref
+ .read(pShopinBitService)
+ .client
+ .submitAddress(
+ widget.model.apiTicketId,
+ shipping: Address(
+ firstName: firstName,
+ lastName: lastName,
+ street: street,
+ zip: postalCode,
+ city: city,
+ country: country,
+ ),
+ billing: billingAddress,
+ );
- paymentInfo = await fetchShopInBitPaymentInfo(
- ref,
- widget.model.apiTicketId,
- );
- } catch (e) {
- debugPrint("submitAddress threw: $e");
- } finally {
- if (mounted) setState(() => _submitting = false);
+ if (resp.hasError) {
+ // Sandbox may fail here; continue anyway.
+ debugPrint("submitAddress failed: ${resp.exception?.message}");
}
+
+ paymentInfo = await fetchShopInBitPaymentInfo(
+ ref,
+ widget.model.apiTicketId,
+ );
+ } catch (e) {
+ debugPrint("submitAddress threw: $e");
+ } finally {
+ if (mounted) setState(() => _submitting = false);
}
if (!mounted) return;
+ if (paymentInfo == null || paymentInfo.paymentLinks.isEmpty) {
+ // No live invoice; don't open a payment view with empty addresses.
+ await _showPaymentLoadError(
+ "We couldn't load the payment details for this order. "
+ "Please try again in a moment.",
+ );
+ return;
+ }
+
unawaited(
Navigator.of(context).pushNamed(
ShopInBitPaymentView.routeName,
@@ -266,6 +281,19 @@ class _ShopInBitShippingViewState extends ConsumerState<ShopInBitShippingView> {
);
}
+ Future<void> _showPaymentLoadError(String message) async {
+ await showDialog<void>(
+ context: context,
+ useRootNavigator: Util.isDesktop,
+ builder: (context) => StackOkDialog(
+ title: "Couldn't load payment details",
+ maxWidth: Util.isDesktop ? 500 : null,
+ message: message,
+ desktopPopRootNavigator: Util.isDesktop,
+ ),
+ );
+ }
+
// Read-only display of the locked delivery country: it was fixed when the
// offer was priced and can't change here.
Widget _buildLockedCountryField() {
diff --git a/lib/route_generator.dart b/lib/route_generator.dart
index df03fd5..9c98d6f 100644
--- a/lib/route_generator.dart
+++ b/lib/route_generator.dart
@@ -1259,12 +1259,12 @@ class RouteGenerator {
return _routeError("${settings.name} invalid args: ${args.toString()}");
case ShopInBitPaymentView.routeName:
- if (args is (ShopInBitOrderModel, PaymentInfo?)) {
+ if (args is (ShopInBitOrderModel, PaymentInfo)) {
return getRoute(
shouldUseMaterialRoute: useMaterialPageRoute,
builder: (_) => ShopInBitPaymentView(
model: args.$1,
- initialPaymentInfo: args.$2,
+ paymentInfo: args.$2,
),
settings: RouteSettings(name: settings.name),
);
diff --git a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart
index e5561b4..f97e6f2 100644
--- a/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart
+++ b/lib/widgets/dialogs/nested_navigator_dialog/nested_navigator_dialog_route_generator.dart
@@ -196,11 +196,11 @@ abstract final class NestedNavigatorDialogRouteGenerator {
);
case ShopInBitPaymentView.routeName:
- if (args is (ShopInBitOrderModel, PaymentInfo?)) {
+ if (args is (ShopInBitOrderModel, PaymentInfo)) {
return getRoute(
builder: (_) => ShopInBitPaymentView(
model: args.$1,
- initialPaymentInfo: args.$2,
+ paymentInfo: args.$2,
),
settings: RouteSettings(name: settings.name),
);
@@ -208,7 +208,7 @@ abstract final class NestedNavigatorDialogRouteGenerator {
return _routeError(
"${settings.name} invalid args\n"
"Got ${args.runtimeType}\n"
- "Expected (ShopInBitOrderModel, PaymentInfo?)",
+ "Expected (ShopInBitOrderModel, PaymentInfo)",
);
case CakePayVendorsView.routeName:
Why this scored 37/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.