fix(shopinbit): drop the by-customer car ticket fallback
What changed, and why it matters
This commit removes a fallback mechanism in Stack Wallet's ShopInBit car-research payment flow. Previously, if the server didn't immediately provide the real customer-support ticket ID, the app would try to guess it by listing all tickets associated with the customer and picking the newest one that wasn't already known. That fallback is now deleted; the app simply waits for the server to supply the real ticket ID directly. The change is described as a functional fix, not a security fix, but removing a heuristic that touches other customer tickets reduces the risk of accidentally opening or acting on the wrong ticket.
Treat as a hardening/functional cleanup change rather than an urgent security patch. Review whether any other code paths still rely on `getByCustomerKey` or `adoptRealCarTicket` (the diff shows they are deleted, but verify no stale imports or tests remain). Confirm that the server-side `realTicketId` is now reliably populated before the client reaches the finalized state, especially in sandbox environments, to avoid users being left without an open ticket.
Security signals we found
Removed a client-side heuristic that enumerates customer tickets and selects a candidate when the canonical ticket ID is missing
Eliminated a fallback that could, in race conditions, select and act upon an unintended ticket if the receipt/known filter failed or the newest ticket was not the expected car-research chat
Reduced attack surface by no longer calling `_ticketsByCustomer` and `getByCustomerKey` during payment finalization
No explicit security claim, CVE, or advisory is present in the commit or supplied references
Evidence from the diff
The patch deletes getByCustomerKey() in the Drift DAO, removes _receiptTicketId state and the retry/adoption loop in shopinbit_car_research_payment_view.dart, and deletes adoptRealCarTicket() from shopinbit_service.dart. The old code polled getCarResearchInvoiceStatus, and if realTicketId was absent it called _ticketsByCustomer(key), filtered out the receipt ticket and already-known tickets, refreshed the newest candidate, and used that as the real car ticket. This by-customer enumeration fallback is replaced with direct reliance on realTicketId from the finalized status. No explicit security vulnerability is stated in the commit; the rationale is that the webhook creates the ticket regardless, so the fallback is unnecessary.
Changed components
lib/db/drift/shared_db/shared_database.dartlib/pages/shopinbit/shopinbit_car_research_payment_view.dartlib/services/shopinbit/shopinbit_service.dartInspect captured patch +4 / −80
diff --git a/lib/db/drift/shared_db/shared_database.dart b/lib/db/drift/shared_db/shared_database.dart
index ec39151..70a9aca 100644
--- a/lib/db/drift/shared_db/shared_database.dart
+++ b/lib/db/drift/shared_db/shared_database.dart
@@ -80,13 +80,6 @@ class ShopInBitTicketsDao extends DatabaseAccessor<SharedDatabase>
)..where((t) => t.apiTicketId.equals(apiTicketId))).watchSingleOrNull();
}
- Future<List<ShopInBitTicket>> getByCustomerKey(String customerKey) {
- return (select(shopInBitTickets)
- ..where((t) => t.customerKey.equals(customerKey))
- ..orderBy([(t) => OrderingTerm.desc(t.createdAt)]))
- .get();
- }
-
/// All tickets for the active customer key, newest first.
Stream<List<ShopInBitTicket>> watchByCustomerKey(String customerKey) {
return (select(shopInBitTickets)
diff --git a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
index 2896158..e882976 100644
--- a/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
+++ b/lib/pages/shopinbit/shopinbit_car_research_payment_view.dart
@@ -56,10 +56,8 @@ class _ShopInBitCarResearchPaymentViewState
String _statusString = "ready_to_pay";
String? _additional;
bool _finalized = false;
- // From the finalized status: the real ticket is the customer chat, the
- // receipt is just the paid-fee receipt.
+ // The real car ticket id (the customer chat) from the finalized status.
int? _realTicketId;
- int? _receiptTicketId;
List<String> _methods = [];
List<String> _addresses = [];
int _selectedMethod = 0;
@@ -349,7 +347,6 @@ class _ShopInBitCarResearchPaymentViewState
_additional = _status!.additional;
_finalized = _status!.finalized;
_realTicketId = _status!.realTicketId;
- _receiptTicketId = _status!.receiptTicketId;
});
if (_isTerminal) {
_pollTimer?.cancel();
@@ -385,35 +382,10 @@ class _ShopInBitCarResearchPaymentViewState
setState(() => _flowState = _PaymentFlowState.finalizing);
_pollTimer?.cancel();
- final service = ref.read(pShopinBitService);
-
try {
- // The finalized status usually gives us the real car ticket id (the
- // customer chat), so open that. It can be null for a bit (sandbox, or
- // while the ticket is still being created), so fall back to by-customer,
- // using the receipt id to skip the receipt ticket. The BTCPay webhook
- // creates the ticket either way.
- int? realId = _realTicketId;
- const int maxAttempts = 8;
- for (
- int attempt = 0;
- attempt < maxAttempts && realId == null;
- attempt++
- ) {
- // Re-poll the status first in case the real ticket id has appeared.
- final statusResp = await service.client.getCarResearchInvoiceStatus(
- widget.invoice.btcpayInvoice,
- );
- realId = statusResp.value?.realTicketId;
- // Fall back to the by-customer heuristic, excluding the receipt id.
- realId ??= await service.adoptRealCarTicket(
- statusResp.value?.receiptTicketId ?? _receiptTicketId ?? 0,
- );
- if (realId == null && attempt < maxAttempts - 1) {
- final int seconds = (1 << (attempt + 1)).clamp(2, 15).toInt();
- await Future<void>.delayed(Duration(seconds: seconds));
- }
- }
+ // The finalized status carries the real car ticket id (the customer
+ // chat), so open that. The BTCPay webhook creates the ticket regardless.
+ final int? realId = _realTicketId;
if (!mounted) return;
setState(() => _flowState = _PaymentFlowState.complete);
diff --git a/lib/services/shopinbit/shopinbit_service.dart b/lib/services/shopinbit/shopinbit_service.dart
index 0061eca..107da76 100644
--- a/lib/services/shopinbit/shopinbit_service.dart
+++ b/lib/services/shopinbit/shopinbit_service.dart
@@ -181,47 +181,6 @@ class ShopInBitService {
return ref;
}
- /// Fallback for finding the real car research ticket when the status endpoint
- /// hasn't populated real_ticket_id yet (sandbox, or briefly while the ticket
- /// is being created).
- ///
- /// The fee receipt id can't be polled by the customer key (403s); the real
- /// ticket is a separate id that shows up in by-customer. Grab the newest
- /// ticket we don't already track (not the receipt), hydrate it, and return
- /// its id, or null if it's not there yet.
- Future<int?> adoptRealCarTicket(int receiptTicketId) async {
- final String key = await ensureCustomerKey();
- final ApiResponse<List<TicketRef>> resp = await _ticketsByCustomer(key);
- if (resp.hasError || resp.value == null) return null;
-
- final Set<int> known = (await db.shopInBitTicketsDao.getByCustomerKey(
- key,
- )).map((t) => t.apiTicketId).toSet();
-
- final List<TicketRef> candidates =
- resp.value!
- .where((t) => t.id != receiptTicketId && !known.contains(t.id))
- .toList()
- ..sort((a, b) => b.id.compareTo(a.id));
-
- // Newest first; the receipt 403s (no row written) so it gets skipped.
- for (final TicketRef ref in candidates) {
- try {
- await _refreshRef(ref, key);
- } catch (e, s) {
- Logging.instance.w(
- "Failed to refresh candidate ticket ${ref.id}, trying next",
- error: e,
- stackTrace: s,
- );
- }
- if (await db.shopInBitTicketsDao.getByApiId(ref.id) != null) {
- return ref.id;
- }
- }
- return null;
- }
-
Future<bool> sendMessage(int apiTicketId, String message) async {
final ApiResponse<Map<String, dynamic>> resp = await client.sendMessage(
apiTicketId,
Why this scored 22/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.