fix(shopinbit): coin selection payment UI and customer key 403 errors
What changed, and why it matters
This commit is a routine bug-fix and UI update for the ShopInBit integration in Stack Wallet. It changes how users pick a cryptocurrency to pay with, stops the app from making API calls that fail with a '403 forbidden' error for car-research tickets, and makes sure a customer key is loaded before those API calls. There is no clear security vulnerability being fixed; the changes appear to be functional improvements and error handling.
Treat as a normal functional/UI fix. Review the car-research authorization behavior on the server side to confirm 403 responses are expected and not a symptom of a misconfiguration. No urgent security action is indicated by the diff alone.
Security signals we found
403 errors are handled by skipping API calls for car-research tickets rather than by changing server-side authorization
Customer key is preloaded and sent with car-research invoice/payment requests
UI payment flow changed from shared QR/address display to per-coin selection rows
No input validation, authentication, or encryption changes visible
Evidence from the diff
The patch refactors shopinbit_payment_view.dart from a tab/QR/address display to a coin list with per-coin ‘PAY NOW’ or info actions. It adds guards in shopinbit_ticket_detail.dart and shopinbit_tickets_view.dart to skip /tickets/:id/* API calls for car-research tickets because the backend returns 403 for those. It also preloads the customer key in ShopInBitService.client and includes _externalCustomerKey in /car-research/invoice and /car-research/log-payment request bodies. No cryptographic, authorization-bypass, injection, or data-leakage fix is evident from the diff alone.
Changed components
lib/pages/shopinbit/shopinbit_payment_view.dartlib/pages/shopinbit/shopinbit_ticket_detail.dartlib/pages/shopinbit/shopinbit_tickets_view.dartlib/services/shopinbit/shopinbit_service.dartlib/services/shopinbit/src/client.dartInspect captured patch +256 / −169
diff --git a/lib/pages/shopinbit/shopinbit_payment_view.dart b/lib/pages/shopinbit/shopinbit_payment_view.dart
index eac9d42..e06acb4 100644
--- a/lib/pages/shopinbit/shopinbit_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_payment_view.dart
@@ -1,4 +1,5 @@
import 'dart:async';
+import 'dart:io';
import 'package:decimal/decimal.dart';
import 'package:flutter/gestures.dart';
@@ -16,6 +17,7 @@ import '../../providers/providers.dart';
import '../../route_generator.dart';
import '../../services/shopinbit/shopinbit_service.dart';
import '../../services/shopinbit/src/models/payment.dart';
+import '../../themes/coin_icon_provider.dart';
import '../../themes/stack_colors.dart';
import '../../utilities/address_utils.dart';
import '../../utilities/amount/amount.dart';
@@ -29,7 +31,6 @@ import '../../widgets/desktop/desktop_dialog.dart';
import '../../widgets/desktop/desktop_dialog_close_button.dart';
import '../../widgets/desktop/primary_button.dart';
import '../../widgets/desktop/secondary_button.dart';
-import '../../widgets/qr.dart';
import '../../widgets/rounded_white_container.dart';
import 'shopinbit_send_from_view.dart';
import 'shopinbit_tickets_view.dart';
@@ -330,15 +331,7 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
}
void _popToTickets() {
- Navigator.of(context).popUntil((route) {
- if (route.settings.name == ShopInBitTicketsView.routeName) {
- return true;
- }
- if (route.isFirst) {
- return true;
- }
- return false;
- });
+ Navigator.of(context).pop();
}
void _navigateToSendFrom({
@@ -379,28 +372,11 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
}
}
- void _copyAddress(BuildContext context) {
- Clipboard.setData(ClipboardData(text: _currentAddress));
- showFloatingFlushBar(
- type: FlushBarType.info,
- message: "Copied to clipboard",
- iconAsset: Assets.svg.copy,
- context: context,
- );
- }
-
- @override
- Widget build(BuildContext context) {
- final isDesktop = Util.isDesktop;
- final ticker = _selectedMethod < _methods.length
- ? _methods[_selectedMethod].toUpperCase()
- : "";
-
- bool hasWallets = false;
+ bool _hasWalletForTicker(String ticker) {
if (ticker == "USDT") {
const usdtAddress = "0xdac17f958d2ee523a2206206994597c13d831ec7";
- hasWallets = ref
- .watch(pWallets)
+ return ref
+ .read(pWallets)
.wallets
.any(
(w) =>
@@ -410,12 +386,108 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
} else {
final coin = AppConfig.getCryptoCurrencyForTicker(ticker);
if (coin != null) {
- hasWallets = ref
- .watch(pWallets)
+ return ref
+ .read(pWallets)
.wallets
.any((e) => e.info.coin == coin);
}
}
+ return false;
+ }
+
+ String? _parseBip21Amount(String bip21Uri) {
+ final parsed = AddressUtils.parsePaymentUri(bip21Uri);
+ String? amountStr = parsed?.amount;
+ if (amountStr == null || amountStr.isEmpty) {
+ final uri = Uri.tryParse(bip21Uri);
+ if (uri != null) {
+ amountStr = uri.queryParameters['amount'];
+ }
+ }
+ return (amountStr != null && amountStr.isNotEmpty) ? amountStr : null;
+ }
+
+ void _onOwnedCoinTap(int methodIndex) {
+ if (!_payNowEnabled) return;
+ _selectedMethod = methodIndex;
+ _confirmPayment();
+ }
+
+ void _onUnownedCoinTap(int methodIndex) {
+ if (_isExpiredOrInvalid || _isTerminal) return;
+ final ticker = _methods[methodIndex].toUpperCase();
+ final address = _addresses[methodIndex];
+
+ showModalBottomSheet(
+ context: context,
+ builder: (ctx) => Padding(
+ padding: const EdgeInsets.all(24),
+ child: Column(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ Text(
+ "$ticker Payment",
+ style: STextStyles.pageTitleH2(context),
+ ),
+ const SizedBox(height: 16),
+ GestureDetector(
+ onTap: () {
+ Clipboard.setData(ClipboardData(text: address));
+ showFloatingFlushBar(
+ type: FlushBarType.info,
+ message: "Copied to clipboard",
+ iconAsset: Assets.svg.copy,
+ context: context,
+ );
+ },
+ child: RoundedWhiteContainer(
+ child: Row(
+ children: [
+ Expanded(
+ child: Text(
+ address,
+ style: STextStyles.itemSubtitle12(context),
+ ),
+ ),
+ const SizedBox(width: 8),
+ Icon(
+ Icons.copy,
+ size: 14,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.accentColorBlue,
+ ),
+ ],
+ ),
+ ),
+ ),
+ const SizedBox(height: 16),
+ PrimaryButton(
+ label: "CHECK FOR PAYMENT",
+ onPressed: () {
+ Navigator.of(ctx).pop();
+ _checkForPayment();
+ },
+ ),
+ ],
+ ),
+ ),
+ );
+ }
+
+ void _copyAddress(BuildContext context) {
+ Clipboard.setData(ClipboardData(text: _currentAddress));
+ showFloatingFlushBar(
+ type: FlushBarType.info,
+ message: "Copied to clipboard",
+ iconAsset: Assets.svg.copy,
+ context: context,
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ final isDesktop = Util.isDesktop;
const loadingOverlay = Center(
child: SizedBox(
@@ -425,47 +497,84 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
),
);
- final methodSelector = Row(
- children: List.generate(_methods.length, (index) {
- final isSelected = _selectedMethod == index;
- return Expanded(
- child: GestureDetector(
- onTap: () => setState(() => _selectedMethod = index),
- child: Container(
- padding: const EdgeInsets.symmetric(vertical: 10),
- decoration: BoxDecoration(
- border: Border(
- bottom: BorderSide(
- color: isSelected
- ? Theme.of(
- context,
- ).extension<StackColors>()!.accentColorBlue
- : Colors.transparent,
- width: 2,
- ),
- ),
- ),
- child: Text(
- _methods[index],
- textAlign: TextAlign.center,
- style:
- (isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- : STextStyles.itemSubtitle12(context))
- .copyWith(
- color: isSelected
- ? Theme.of(
- context,
- ).extension<StackColors>()!.accentColorBlue
- : null,
- fontWeight: isSelected ? FontWeight.w600 : null,
+ // Build coin rows from _methods/_addresses
+ final coinRows = <Widget>[];
+ for (int i = 0; i < _methods.length; i++) {
+ final ticker = _methods[i].toUpperCase();
+ final coin = AppConfig.getCryptoCurrencyForTicker(ticker);
+ final hasWallet = _hasWalletForTicker(ticker);
+ final amountStr = _addresses[i].isNotEmpty
+ ? _parseBip21Amount(_addresses[i])
+ : null;
+
+ if (i > 0) {
+ coinRows.add(const SizedBox(height: 8));
+ }
+
+ coinRows.add(
+ RoundedWhiteContainer(
+ child: Opacity(
+ opacity: hasWallet ? 1.0 : 0.5,
+ child: InkWell(
+ onTap: hasWallet
+ ? () => _onOwnedCoinTap(i)
+ : () => _onUnownedCoinTap(i),
+ child: Row(
+ children: [
+ if (coin != null)
+ SvgPicture.file(
+ File(ref.watch(coinIconProvider(coin))),
+ width: 24,
+ height: 24,
+ )
+ else
+ SizedBox(
+ width: 24,
+ height: 24,
+ child: Center(
+ child: Text(
+ ticker.substring(0, ticker.length > 2 ? 2 : ticker.length),
+ style: STextStyles.itemSubtitle12(context),
),
+ ),
+ ),
+ const SizedBox(width: 12),
+ Expanded(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ ticker,
+ style: STextStyles.titleBold12(context),
+ ),
+ if (amountStr != null)
+ Text(
+ "$amountStr $ticker",
+ style: STextStyles.itemSubtitle12(context),
+ ),
+ ],
+ ),
+ ),
+ if (hasWallet)
+ Text(
+ "PAY NOW",
+ style: STextStyles.link2(context),
+ )
+ else
+ Icon(
+ Icons.info_outline,
+ size: 18,
+ color: Theme.of(
+ context,
+ ).extension<StackColors>()!.textSubtitle2,
+ ),
+ ],
),
),
),
- );
- }),
- );
+ ),
+ );
+ }
final content = Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
@@ -611,72 +720,8 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
),
],
SizedBox(height: isDesktop ? 24 : 16),
- if (!_isExpiredOrInvalid) ...[
- methodSelector,
- SizedBox(height: isDesktop ? 24 : 16),
- if (_currentAddress.isNotEmpty)
- Center(
- child: QR(data: _currentAddress, size: isDesktop ? 200 : 180),
- ),
- if (_currentAddress.isEmpty)
- Center(
- child: Padding(
- padding: const EdgeInsets.all(32),
- child: Text(
- "No payment address available",
- style: isDesktop
- ? STextStyles.desktopTextSmall(context)
- : STextStyles.itemSubtitle(context),
- ),
- ),
- ),
- SizedBox(height: isDesktop ? 16 : 12),
- if (_currentAddress.isNotEmpty)
- GestureDetector(
- onTap: () => _copyAddress(context),
- child: RoundedWhiteContainer(
- child: Column(
- children: [
- Row(
- children: [
- Text(
- "${_methods[_selectedMethod]} address",
- style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(context)
- : STextStyles.itemSubtitle12(context),
- ),
- const Spacer(),
- Icon(
- Icons.copy,
- size: 14,
- color: Theme.of(
- context,
- ).extension<StackColors>()!.accentColorBlue,
- ),
- const SizedBox(width: 4),
- Text("Copy", style: STextStyles.link2(context)),
- ],
- ),
- const SizedBox(height: 4),
- Row(
- children: [
- Expanded(
- child: Text(
- _currentAddress,
- style: isDesktop
- ? STextStyles.desktopTextExtraExtraSmall(
- context,
- )
- : STextStyles.itemSubtitle12(context),
- ),
- ),
- ],
- ),
- ],
- ),
- ),
- ),
- ],
+ // Coin list (replaces tab selector + QR + address + global button)
+ if (!_isExpiredOrInvalid) ...coinRows,
SizedBox(height: isDesktop ? 16 : 12),
GestureDetector(
onTap: () {
@@ -726,14 +771,6 @@ class _ShopInBitPaymentViewState extends ConsumerState<ShopInBitPaymentView> {
),
),
),
- SizedBox(height: isDesktop ? 16 : 12),
- PrimaryButton(
- label: hasWallets ? "PAY NOW" : "CHECK FOR PAYMENT",
- enabled: _payNowEnabled,
- onPressed: _payNowEnabled
- ? (hasWallets ? _confirmPayment : _checkForPayment)
- : null,
- ),
],
);
diff --git a/lib/pages/shopinbit/shopinbit_ticket_detail.dart b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
index 5977224..76299d1 100644
--- a/lib/pages/shopinbit/shopinbit_ticket_detail.dart
+++ b/lib/pages/shopinbit/shopinbit_ticket_detail.dart
@@ -94,33 +94,40 @@ class _ShopInBitTicketDetailState extends State<ShopInBitTicketDetail> {
super.dispose();
}
+ bool get _isCarResearch => widget.model.category == ShopInBitCategory.car;
+
Future<void> _loadFromApi() async {
setState(() => _loading = true);
try {
final client = ShopInBitService.instance.client;
final id = widget.model.apiTicketId;
- final messagesResp = await client.getMessages(id);
- final statusResp = await client.getTicketStatus(id);
-
- if (!messagesResp.hasError && messagesResp.value != null) {
- final apiMessages = messagesResp.value!;
- widget.model.clearMessages();
- for (final m in apiMessages) {
- widget.model.addMessage(
- ShopInBitMessage(
- text: m.content,
- timestamp: m.timestamp,
- isFromUser: !m.fromAgent,
- ),
- );
+ // Car research tickets created via /car-research/log-payment are not
+ // accessible via /tickets/:id/* endpoints (API returns 403). Skip
+ // those calls for car tickets to avoid log spam. Local data is used.
+ if (!_isCarResearch) {
+ final messagesResp = await client.getMessages(id);
+ final statusResp = await client.getTicketStatus(id);
+
+ if (!messagesResp.hasError && messagesResp.value != null) {
+ final apiMessages = messagesResp.value!;
+ widget.model.clearMessages();
+ for (final m in apiMessages) {
+ widget.model.addMessage(
+ ShopInBitMessage(
+ text: m.content,
+ timestamp: m.timestamp,
+ isFromUser: !m.fromAgent,
+ ),
+ );
+ }
}
- }
- if (!statusResp.hasError && statusResp.value != null) {
- widget.model.status = ShopInBitOrderModel.statusFromTicketState(
- statusResp.value!.state,
- );
+ if (!statusResp.hasError && statusResp.value != null) {
+ widget.model.status = ShopInBitOrderModel.statusFromTicketState(
+ statusResp.value!.state,
+ );
+ }
}
unawaited(
@@ -454,10 +461,37 @@ class _ShopInBitTicketDetailState extends State<ShopInBitTicketDetail> {
),
);
+ final requestDetailsSection = _isCarResearch && model.requestDescription.isNotEmpty
+ ? Padding(
+ padding: EdgeInsets.only(bottom: isDesktop ? 12 : 8),
+ child: RoundedWhiteContainer(
+ child: Column(
+ crossAxisAlignment: CrossAxisAlignment.start,
+ children: [
+ Text(
+ "Request details",
+ style: isDesktop
+ ? STextStyles.desktopTextSmall(context)
+ : STextStyles.titleBold12(context),
+ ),
+ const SizedBox(height: 8),
+ Text(
+ model.requestDescription,
+ style: isDesktop
+ ? STextStyles.desktopTextExtraExtraSmall(context)
+ : STextStyles.itemSubtitle12(context),
+ ),
+ ],
+ ),
+ ),
+ )
+ : const SizedBox.shrink();
+
final body = Column(
children: [
statusBar,
offerBanner,
+ requestDetailsSection,
chatArea,
SizedBox(height: isDesktop ? 12 : 8),
inputBar,
diff --git a/lib/pages/shopinbit/shopinbit_tickets_view.dart b/lib/pages/shopinbit/shopinbit_tickets_view.dart
index b2f9dd9..a6d0c8c 100644
--- a/lib/pages/shopinbit/shopinbit_tickets_view.dart
+++ b/lib/pages/shopinbit/shopinbit_tickets_view.dart
@@ -62,6 +62,9 @@ class _ShopInBitTicketsViewState extends State<ShopInBitTicketsView> {
continue;
}
+ // Car research tickets return 403 on /tickets/:id/* endpoints.
+ if (_tickets[localIdx].category == ShopInBitCategory.car) continue;
+
final statusResp = await service.client.getTicketStatus(ref.id);
if (statusResp.hasError || statusResp.value == null) continue;
diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart
index 5bc0639..1caa8aa 100644
--- a/lib/services/shopinbit/shopinbit_service.dart
+++ b/lib/services/shopinbit/shopinbit_service.dart
@@ -14,11 +14,16 @@ class ShopInBitService {
String? _displayName;
ShopInBitClient get client {
- return _client ??= ShopInBitClient(
- accessKey: kShopInBitAccessKey,
- partnerSecret: kShopInBitPartnerSecret,
- sandbox: true,
- );
+ if (_client == null) {
+ _client = ShopInBitClient(
+ accessKey: kShopInBitAccessKey,
+ partnerSecret: kShopInBitPartnerSecret,
+ sandbox: true,
+ );
+ // Pre-load customer key for ticket detail API calls.
+ loadCustomerKey();
+ }
+ return _client!;
}
String? get customerKey => _customerKey;
diff --git a/lib/services/shopinbit/src/client.dart b/lib/services/shopinbit/src/client.dart
index 5e8c0ff..fe48184 100644
--- a/lib/services/shopinbit/src/client.dart
+++ b/lib/services/shopinbit/src/client.dart
@@ -343,7 +343,11 @@ class ShopInBitClient {
return _request(
'POST',
'/car-research/invoice',
- body: {'billing': billing.toJson()},
+ body: {
+ 'billing': billing.toJson(),
+ if (_externalCustomerKey != null)
+ 'external_customer_key': _externalCustomerKey,
+ },
parse: CarResearchInvoice.fromJson,
);
}
@@ -364,7 +368,11 @@ class ShopInBitClient {
return _request(
'POST',
'/car-research/log-payment',
- body: {'invoice_id': invoiceId},
+ body: {
+ 'invoice_id': invoiceId,
+ if (_externalCustomerKey != null)
+ 'external_customer_key': _externalCustomerKey,
+ },
parse: CarResearchPaymentResult.fromJson,
);
}
Why this scored 24/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.